AI 에이전트 권한을 최소화하는 방법

AI 에이전트 권한을 최소화하는 방법

한눈에 보기

에이전트의 판단 정확도를 높이는 것만으로 안전한 실행을 보장할 수 없다. Prompt injection, 잘못된 추론, 오래된 상태와 재시도는 언제든 발생할 수 있다. 그래서 에이전트가 틀려도 읽을 수 있는 데이터, 변경할 수 있는 리소스, 호출할 수 있는 네트워크와 자격 증명의 수명이 작업에 필요한 최소 범위 안에 머물도록 설계해야 한다.

목차

에이전트가 틀리지 않을 것이라는 가정

코딩 에이전트에게 저장소 정리를 요청했다고 하자. 가장 편한 구현은 사용자 계정의 홈 디렉터리와 shell, network credential을 그대로 제공하는 것이다.

filesystem: full home directory read/write
shell: unrestricted
network: unrestricted egress
cloud: administrator credential
git: push to every repository

에이전트가 요청을 정확히 이해하면 빠르게 작업할 수 있다. 하지만 다음 중 하나만 발생해도 영향 범위가 커진다.

flowchart LR
    A[작은 판단 오류] --> B{에이전트 권한 범위}
    B -- 넓음 --> C[여러 시스템과 데이터에 확산]
    B -- 제한됨 --> D[작업 범위 안에서 실패]

최소 권한은 모델이 안전하다는 가정이 깨졌을 때 피해 범위를 제한한다. NIST는 최소 권한을 사용자 또는 그 대신 동작하는 프로세스가 맡은 작업에 필요한 최소 자원과 권한만 갖게 하는 원칙으로 정의한다. 에이전트도 사용자를 대신하는 프로세스다.

안전성의 기준

“모델이 이 도구를 잘 선택하는가?”와 함께 “잘못 선택해도 시스템이 허용된 범위 밖에서는 실행하지 않는가?”를 검증한다.

최소 권한을 여러 축으로 나눈다

권한을 adminread-only 두 단계로만 나누면 실제 작업 범위를 충분히 표현하지 못한다.

누가      subject: 어느 사용자·service·agent run인가
무엇을    action: read, create, update, delete, send인가
어디에    resource: 어느 project·folder·record인가
언제      time: 얼마 동안 유효한가
어디서    environment: staging인가 production인가
어디로    network: 어떤 host와 port에 연결하는가
얼마나    limits: 횟수·금액·bytes·recipients 한도
왜        purpose: 어떤 승인된 task를 위한 것인가

이를 교차한 결과가 한 실행의 권한이다.

Effective Permission
  = Actor
  ∩ Task
  ∩ Tool
  ∩ Resource
  ∩ Action
  ∩ Environment
  ∩ Time
  ∩ Limits

예를 들어 “문서 초안을 만든다”는 작업은 다음처럼 좁힐 수 있다.

task: draft-weekly-report
actor: user-example
expires_at: 2026-04-28T11:00:00Z

filesystem:
  read:
    - /workspace/source-notes
  write:
    - /workspace/output/report.md
  delete: []

network:
  allow:
    - host: docs.example.test
      port: 443

tools:
  - read_document
  - write_draft

limits:
  max_output_bytes: 1048576
  max_tool_calls: 50

write: /workspace처럼 넓은 prefix보다 구체적인 output 경로를 사용한다. 작업 성격상 여러 파일이 필요하면 새로 만든 task directory 안으로 제한한다.

사람의 권한을 그대로 위임하지 않는다

사용자가 production 관리자라고 해서 에이전트도 같은 권한을 가져야 하는 것은 아니다.

flowchart LR
    U[Human User
broad organizational role] --> P[Task Proposal] P --> D[Delegation Policy] D --> T[Task-scoped Credential] T --> E[Agent Executor]

사람의 장기 권한은 다양한 업무를 위한 상한이다. 에이전트 실행에는 이번 작업에 필요한 일부만 위임한다.

Human:
- projects A, B, C 관리
- production 배포
- billing 확인

Agent task:
- project B의 staging release note 초안 읽기
- 지정 문서 한 곳에 draft 작성

사람의 browser cookie나 개인 access token을 agent runtime에 그대로 복사하면 권한 축소와 감사가 어렵다. Delegation service가 task-bound token을 발급하는 편이 낫다.

{
  "subject": "agent-run-example",
  "delegated_by": "user-example",
  "audience": "document-service",
  "scope": [
    "document:read:project-b",
    "document:create:drafts"
  ],
  "task_id": "task-example-42",
  "expires_at": "2026-04-28T11:00:00Z"
}

이 token은 cloud administrator API나 project A에 사용할 수 없어야 한다. 대상 서비스가 audience, scope, task와 resource-level authorization을 모두 검증한다.

사용자 token을 그대로 전달하지 않는다

Downstream이 agent service를 사용자와 같은 client로 오해하거나 token이 다른 audience에서 재사용되는 confused deputy 문제가 생길 수 있다.

도구별로 실행 주체와 자격 증명을 분리한다

하나의 agent process에 DB, email, cloud, Git credential을 모두 넣으면 어떤 tool이 compromise돼도 모든 권한에 접근할 수 있다.

Agent Orchestrator
├── read_document executor
├── send_email executor
├── deploy_staging executor
└── query_metrics executor

각 executor는 다른 service account와 scope를 가진다.

Executor 허용 금지
Document reader 지정 project 문서 읽기 쓰기·공유 변경
Draft writer drafts folder 파일 생성 기존 파일 삭제
Email sender 승인 template 단건 발송 임의 HTML·대량 발송
Staging deployer staging 특정 service 배포 production·IAM 변경
Metrics reader allowlist query 실행 raw customer log 읽기

Orchestrator는 실제 credential 대신 executor 호출 권한만 갖는다. Executor는 tool schema와 정책을 다시 검사한다.

flowchart LR
    L[LLM] --> O[Orchestrator
no broad credential] O --> P[Policy Gateway] P --> D[Document Executor] P --> M[Mail Executor] P --> S[Staging Deploy Executor]

도구 설명이 read_only: true라고 주장해도 그 metadata가 신뢰하는 registry에서 온 것인지 확인한다. 외부 tool server가 제공한 annotation만으로 credential을 선택하지 않는다.

리소스와 작업 범위를 Capability로 제한한다

전역 권한 대신 특정 객체와 동작을 가리키는 capability를 발급할 수 있다.

{
  "capability_id": "cap-example-42",
  "task_id": "task-example-42",
  "resource": {
    "type": "document",
    "id": "document-example-7"
  },
  "actions": ["read", "append_comment"],
  "constraints": {
    "max_comments": 3,
    "expires_at": "2026-04-28T11:00:00Z"
  }
}

에이전트가 임의 document ID를 생성해도 capability가 가리키는 객체 밖에는 접근할 수 없다.

type Capability = {
  taskId: string;
  resourceType: "document";
  resourceId: string;
  actions: Array<"read" | "append_comment">;
  expiresAt: Date;
  maxComments: number;
};

function permits(input: {
  capability: Capability;
  taskId: string;
  documentId: string;
  action: "read" | "append_comment";
}): boolean {
  return (
    input.capability.taskId === input.taskId &&
    input.capability.resourceId === input.documentId &&
    input.capability.actions.includes(input.action) &&
    input.capability.expiresAt > new Date()
  );
}

Capability 문자열 자체가 탈취될 수 있으므로 서명, audience, 짧은 만료, 단일 사용 nonce와 안전한 저장이 필요하다.

리소스 prefix를 사용할 때 경계를 명확히 한다.

허용 의도: project/12/*
잘못된 문자열 prefix: project/12 also matches project/123

문자열 시작 여부가 아니라 구조화한 resource hierarchy와 canonical ID로 검사한다.

짧은 수명의 자격 증명을 사용한다

환경 변수에 장기 API key를 넣으면 agent run이 끝난 뒤에도 유효하고 로그나 child process로 퍼질 수 있다.

long-lived credential
  = 넓은 노출 시간
  + 회수 어려움
  + task와 연결하기 어려운 audit

실행 직전에 짧은 token을 발급하고 작업이 끝나면 만료되게 한다.

sequenceDiagram
    participant O as Orchestrator
    participant B as Credential Broker
    participant E as Executor
    participant R as Resource Server

    O->>B: task와 승인 증거 제출
    B-->>E: audience-bound short token
    E->>R: scoped operation
    R-->>E: result
    Note over E,R: token expires shortly

Token claim 예시다.

{
  "sub": "agent-executor-example",
  "aud": "document-api",
  "scope": "document.append_comment",
  "resource_id": "document-example-7",
  "task_id": "task-example-42",
  "jti": "token-example-92",
  "iat": 1777373400,
  "exp": 1777373700
}

단순히 만료를 5분으로 설정했다고 충분한 것은 아니다.

장기 작업은 긴 token 하나보다 단계별 lease와 재인가를 사용할 수 있다.

네트워크 접근도 권한이다

Filesystem이 read-only여도 자유로운 network egress가 있으면 읽은 데이터를 외부로 보낼 수 있다.

read sensitive file
  + unrestricted HTTPS
  = potential exfiltration path

Domain allowlist만으로도 부족할 수 있다.

Network policy는 목적지, port, protocol, redirect, DNS resolution과 proxy를 함께 다룬다.

egress:
  default: deny
  allow:
    - host: docs.example.test
      port: 443
      methods: [GET]
      max_response_bytes: 5242880
    - host: package-registry.example.test
      port: 443
      methods: [GET]

HTTP method 제한은 network layer만으로 완전한 authorization이 아니다. API credential과 resource server 정책이 함께 필요하다.

외부 URL fetch 도구는 SSRF를 막아야 한다.

function isBlockedAddress(hostname: string): boolean {
  return (
    hostname === "localhost" ||
    hostname.endsWith(".internal") ||
    hostname === "169.254.169.254"
  );
}

이 간단한 함수는 개념 예시일 뿐 실제 방어로 충분하지 않다. DNS를 resolve한 모든 IP의 private·link-local 범위를 검사하고 redirect마다 재검증하며, 가능하면 전용 egress proxy를 사용한다.

검색 결과의 URL도 신뢰하지 않는다

모델이나 문서가 만든 URL을 허용된 목적지라고 가정하지 않는다. Network broker가 독립적으로 정책을 검사한다.

파일 시스템 권한은 경로와 동작으로 나눈다

Agent가 workspace 하나만 사용해도 모든 파일에 같은 권한이 필요한 것은 아니다.

/workspace
├── source/       read
├── generated/    read/write
├── secrets/      no access
├── .git/         read, write restricted
└── config/       read

경로 검사는 .., symlink, case sensitivity, mount를 고려해야 한다.

import { realpath } from "node:fs/promises";
import { relative, resolve } from "node:path";

async function isWithinAllowedRoot(
  candidate: string,
  allowedRoot: string,
): Promise<boolean> {
  const root = await realpath(resolve(allowedRoot));
  const target = await realpath(resolve(candidate));
  const rel = relative(root, target);

  return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/"));
}

새 파일은 아직 realpath가 실패할 수 있다. 부모 directory의 canonical path를 확인하고 파일 생성 시 symlink race를 막는 OS 수준 sandbox가 필요하다. 애플리케이션 path check만으로 confinement를 구현하지 않는다.

작업별 임시 directory를 주고 완료 artifact만 밖으로 복사하는 방법이 단순하다.

/sandbox/tasks/task-example-42
├── input/      read-only mount
├── work/       read-write
└── output/     artifact collection

Git 권한도 분리한다.

read repository
write working tree
create local commit
push branch
merge pull request
modify branch protection

코드를 수정할 권한과 protected branch에 push할 권한은 다르다. 에이전트가 작업 branch를 만들 수 있어도 merge는 사람과 CI가 결정하도록 할 수 있다.

Sandbox 설계는 에이전트 작업을 샌드박스에서 실행하기에서 더 자세히 이어간다.

읽기 권한도 데이터 유출을 만들 수 있다

읽기 도구는 시스템 상태를 바꾸지 않지만 민감 정보를 노출할 수 있다.

read email
read private document
query production database
list cloud resources
read environment variables

한 번 읽은 정보는 모델 context, 로그, tool output, 외부 API 요청으로 퍼질 수 있다.

flowchart LR
    D[Private Data] --> T[Read Tool]
    T --> C[Model Context]
    C --> L[Logs/Traces]
    C --> O[Tool Output]
    C --> X[External Model/API]

따라서 읽기에도 다음 제한을 둔다.

-- 나쁜 예: 전체 customer row
SELECT *
FROM customers
WHERE organization_id = :organization_id;
-- 필요한 필드와 수량만
SELECT id, display_name, support_tier
FROM customers
WHERE organization_id = :organization_id
ORDER BY id
LIMIT 100;

SQL 자체를 모델에게 자유롭게 생성·실행하게 하기보다 승인된 query template과 parameter를 제공한다.

정책 검사는 모델 밖에서 수행한다

Prompt에 “production은 절대 수정하지 마”라고 쓰는 것은 방어층 하나일 뿐 강제 가능한 정책이 아니다.

모델 지시:
- production 수정 금지

실행 시스템:
- production credential 존재
- network 접근 허용
- tool이 environment 인자 수용

공격 입력이나 추론 오류로 prompt 규칙이 깨지면 실행된다. 실제 executor가 거부해야 한다.

type PolicyInput = {
  actorId: string;
  taskId: string;
  tool: string;
  action: string;
  resource: {
    type: string;
    id: string;
    environment: "development" | "staging" | "production";
  };
  approved: boolean;
  now: Date;
};

type PolicyDecision =
  | { allowed: true; constraints: Record<string, unknown> }
  | { allowed: false; code: string };

정책은 명시적으로 deny한다.

function evaluatePolicy(input: PolicyInput): PolicyDecision {
  if (input.resource.environment === "production") {
    return {
      allowed: false,
      code: "production_access_not_available_to_this_agent",
    };
  }

  if (input.action === "delete") {
    return {
      allowed: false,
      code: "delete_not_allowed",
    };
  }

  if (input.action === "write" && !input.approved) {
    return {
      allowed: false,
      code: "approval_required",
    };
  }

  return {
    allowed: true,
    constraints: {
      maxOperations: 1,
    },
  };
}

모델은 정책 결정 이유를 조작할 수 없다. Executor가 인증된 actor, DB의 approval, resource metadata로 입력을 구성한다.

재구성한 권한 정책과 Executor 예제

문서 comment 추가 작업을 예로 든다. 실제 프로젝트 코드가 아닌 설명용 구현이다.

import { z } from "zod";

const appendCommentSchema = z.object({
  documentId: z.string().min(1),
  body: z.string().min(1).max(2_000),
  expectedVersion: z.number().int().nonnegative(),
}).strict();

type AppendCommentCommand = z.infer<
  typeof appendCommentSchema
>;

Task grant는 허용된 document와 횟수를 갖는다.

type TaskGrant = {
  taskId: string;
  delegatedBy: string;
  audience: "document-executor";
  allowedDocumentIds: string[];
  allowedActions: Array<"read" | "append_comment">;
  remainingWrites: number;
  expiresAt: Date;
};

권한 검증은 사용자 membership과 task grant를 모두 확인한다.

async function authorizeAppendComment(input: {
  actorId: string;
  command: AppendCommentCommand;
  grant: TaskGrant;
}) {
  if (input.grant.expiresAt <= new Date()) {
    return { allowed: false as const, reason: "grant_expired" };
  }

  if (input.grant.audience !== "document-executor") {
    return { allowed: false as const, reason: "wrong_audience" };
  }

  if (!input.grant.allowedActions.includes("append_comment")) {
    return { allowed: false as const, reason: "action_not_granted" };
  }

  if (
    !input.grant.allowedDocumentIds.includes(
      input.command.documentId,
    )
  ) {
    return { allowed: false as const, reason: "resource_not_granted" };
  }

  if (input.grant.remainingWrites < 1) {
    return { allowed: false as const, reason: "write_limit_exceeded" };
  }

  const membership = await documentAcl.findMembership(
    input.actorId,
    input.command.documentId,
  );

  if (!membership?.canComment) {
    return { allowed: false as const, reason: "actor_not_authorized" };
  }

  return { allowed: true as const };
}

실행 시 document version을 다시 확인하고 grant 사용량을 원자적으로 차감한다.

async function appendCommentWithGrant(input: {
  operationId: string;
  actorId: string;
  rawCommand: unknown;
  grantId: string;
}) {
  const command = appendCommentSchema.parse(input.rawCommand);

  return database.transaction(async (tx) => {
    const grant = await tx.taskGrants.lockById(input.grantId);
    const decision = await authorizeAppendComment({
      actorId: input.actorId,
      command,
      grant,
    });

    if (!decision.allowed) {
      return {
        kind: "denied" as const,
        reason: decision.reason,
      };
    }

    const document = await tx.documents.lockById(
      command.documentId,
    );

    if (document.version !== command.expectedVersion) {
      return {
        kind: "stale" as const,
        reason: "document_changed_after_proposal",
      };
    }

    const existing = await tx.comments.findByOperationId(
      input.operationId,
    );

    if (existing) {
      return {
        kind: "already_applied" as const,
        commentId: existing.id,
      };
    }

    const comment = await tx.comments.create({
      operationId: input.operationId,
      documentId: command.documentId,
      body: command.body,
      createdBy: input.actorId,
    });

    await tx.taskGrants.decrementRemainingWrites(grant.id);

    return {
      kind: "succeeded" as const,
      commentId: comment.id,
    };
  });
}

이 예제는 schema validation, 사용자 권한, task grant, 현재 version, write 횟수와 멱등 operation을 교차 검사한다.

Confused Deputy와 Token Passthrough를 막는다

Agent gateway가 사용자 token을 받아 downstream API에 그대로 전달하면 gateway가 원래 의도하지 않은 권한의 대리인이 될 수 있다.

sequenceDiagram
    participant U as User
    participant A as Agent Gateway
    participant M as MCP/Tool Server
    participant D as Downstream API

    U->>A: token for Agent Gateway
    A->>M: same token
    M->>D: same token passthrough
    Note over M,D: audience와 권한 경계가 흐려짐

각 resource server용 token을 별도로 발급한다.

Token A:
audience = agent-gateway

Token B:
audience = document-tool
scope = document.comment

Token C:
audience = document-api
scope = comment.create

Tool server는 받은 token이 자신을 대상으로 발급됐는지 검사하고 downstream에는 자신의 별도 credential 또는 token exchange 결과를 사용한다.

function validateAudience(
  claims: { aud: string | string[] },
  expected: string,
): boolean {
  const audiences = Array.isArray(claims.aud)
    ? claims.aud
    : [claims.aud];

  return audiences.includes(expected);
}

Audience 검사만으로 충분하지 않다. Issuer, signature, expiry, nonce, scope와 resource-level permission을 검증한다.

권한 상승과 Break Glass

에이전트가 더 많은 권한이 필요하다고 판단할 수 있다. 자동으로 scope를 확대하게 해서는 안 된다.

현재 grant:
- staging log read

모델 제안:
- production database read 필요

권한 상승은 새로운 요청이다.

stateDiagram-v2
    [*] --> Limited
    Limited --> EscalationRequested
    EscalationRequested --> Denied
    EscalationRequested --> TimeBoundGrant: approved
    TimeBoundGrant --> Revoked: task complete
    TimeBoundGrant --> Expired

승인 화면에는 추가되는 범위와 이유, 만료, 데이터 노출을 보여 준다.

추가 요청 권한:
- production error log 읽기

범위:
- service=article-api
- time=최근 15분
- fields=timestamp, status, error_kind
- 최대 1,000건

제외:
- request body
- authorization header
- customer email

만료:
- 10분

Critical operation은 에이전트에 grant하지 않고 사람 운영자가 별도 Runbook으로 수행할 수 있다.

Break-glass credential은 평상시 agent runtime에 넣지 않는다. 별도 인증, 짧은 만료, 사유와 incident ID, 사후 review를 요구한다.

감사와 탐지 지표

최소 권한은 설정만으로 끝나지 않는다. 실제 거부와 사용을 관측한다.

agent_authorization_decisions_total{
  tool,
  action,
  decision,
  reason
}

agent_grants_issued_total{
  scope,
  risk_level
}

agent_grant_duration_seconds
agent_scope_escalations_total{decision}
agent_credential_refresh_total{reason}
agent_egress_denied_total{destination_class}

Resource ID와 user ID는 metric label에 넣지 않고 audit log에 둔다.

{
  "event": "agent_authorization_denied",
  "task_id": "task-example-42",
  "actor_id": "user-example",
  "agent_run_id": "run-example-8",
  "tool": "append_document_comment",
  "action": "append_comment",
  "resource_type": "document",
  "resource_id": "document-example-7",
  "decision": "deny",
  "reason": "grant_expired",
  "policy_version": "agent-policy-4.2",
  "occurred_at": "2026-04-28T11:02:00Z"
}

Audit에는 token 원문과 민감한 tool argument를 남기지 않는다.

다음 이상을 탐지한다.

거부가 많다고 정책이 나쁘다고 단정하지 않는다. 공격이나 모델 오류를 제대로 막고 있을 수 있다. 거부 이유와 사용자 결과를 함께 본다.

최소 권한을 테스트하는 방법

허용 경로만 테스트하면 정책이 지나치게 넓은지 알 수 없다. Negative test가 중요하다.

import { describe, expect, it } from "vitest";

describe("agent policy", () => {
  it("production write를 거부한다", () => {
    const decision = evaluatePolicy({
      actorId: "user-example",
      taskId: "task-example",
      tool: "write_document",
      action: "write",
      resource: {
        type: "document",
        id: "document-example",
        environment: "production",
      },
      approved: true,
      now: new Date(),
    });

    expect(decision).toEqual({
      allowed: false,
      code: "production_access_not_available_to_this_agent",
    });
  });
});

테스트 행렬을 만든다.

조건 기대
허용 resource + 허용 action 통과
다른 project ID 거부
만료된 grant 거부
잘못된 token audience 거부
task 취소 후 실행 거부
승인 뒤 resource version 변경 stale
write 횟수 초과 거부
redirect가 private IP로 이동 network 거부
symlink로 허용 root 탈출 filesystem 거부
external tool이 read-only 주장 registry 미등록이면 거부

통합 테스트에서는 실제 sandbox와 credential broker를 사용한다.

1. 제한된 task grant 발급
2. 허용된 read 성공 확인
3. 같은 token으로 다른 resource 접근 실패 확인
4. network deny 확인
5. 만료 뒤 접근 실패 확인
6. audit event 생성 확인
7. task 종료 뒤 secret과 sandbox 제거 확인

권한 policy도 코드처럼 version control하고 변경 diff를 review한다.

policy diff:
- document.read: project-B
+ document.read: organization-wide

한 줄이지만 영향 범위가 크게 넓어진다. Policy test에서 새로 허용되는 subject-resource-action 조합을 보여 주면 review가 쉬워진다.

Canary보다 먼저 Shadow Decision

새 정책을 바로 강제하지 않고 기존 요청에 대해 새 policy의 allow/deny 차이를 기록해 예상하지 못한 권한 확대와 업무 차단을 찾을 수 있다.

마무리

AI 에이전트의 최소 권한은 tool 목록을 몇 개 숨기는 수준이 아니다. 사용자와 agent run, 작업 목적, resource, action, 환경, 시간, network와 횟수를 교차해 실제 실행 가능 범위를 만든다.

에이전트가 실수할 가능성을 0으로 만들려고 하기보다, 실수하더라도 이번 작업의 작은 capability 안에서만 영향을 만들도록 설계한다.

실무 적용 기준은 다음과 같다.

  1. 사람의 장기 권한을 agent runtime에 그대로 전달하지 않는다.
  2. Task별 subject와 만료되는 grant를 발급한다.
  3. Tool별 executor와 credential을 분리한다.
  4. Resource ID와 허용 action을 capability에 명시한다.
  5. Token audience, scope, expiry와 task binding을 검증한다.
  6. 파일은 canonical path와 OS sandbox로 제한한다.
  7. Network egress를 default deny하고 redirect와 DNS를 재검증한다.
  8. 읽기에도 field·row·bytes·시간 범위 제한을 둔다.
  9. 모델 prompt가 아니라 policy engine과 resource server가 권한을 강제한다.
  10. 권한 상승을 새 proposal과 승인으로 처리한다.
  11. Token passthrough를 피하고 resource별 credential을 사용한다.
  12. Negative test와 audit로 실제 피해 범위를 증명한다.

에이전트가 유용해질수록 더 많은 시스템에 연결하고 싶어진다. 이때 하나의 강력한 계정을 제공하는 대신 작업 단위의 작은 권한을 조합해야 기능을 늘리면서도 실패 범위를 통제할 수 있다.

참고 자료

관련 노트